"""Main application window for the filter pipeline viewer."""

from __future__ import annotations

import json
import math
import os
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple

from ..qt_compat import cv2
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import (
    QApplication,
    QFileDialog,
    QCheckBox,
    QComboBox,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QMessageBox,
    QProgressDialog,
    QPushButton,
    QShortcut,
    QVBoxLayout,
    QWidget,
)

from ..core.context import PipelineContext
from ..core.operations import OPERATIONS
from ..core.scaling import (
    PARAM_SCALE_AREA,
    PARAM_SCALE_FLOAT,
    PARAM_SCALE_INT,
    PARAM_SCALE_RULES,
)
from ..core.types import OperationDefinition, PipelineStep
from ..core.utils import ensure_gray, to_display
from .controls import ParameterPanel
from .region import RegionSelectionDialog
from .tabs import ImageTabWidget


@dataclass
class ContextSnapshot:
    image: np.ndarray
    source_bgr: Optional[np.ndarray]
    fourier_shift: Optional[np.ndarray]
    fourier_source: Optional[np.ndarray]

    @classmethod
    def from_context(cls, ctx: PipelineContext) -> "ContextSnapshot":
        return cls(
            image=ctx.image.copy(),
            source_bgr=ctx.source_bgr.copy() if ctx.source_bgr is not None else None,
            fourier_shift=ctx.fourier_shift.copy() if ctx.fourier_shift is not None else None,
            fourier_source=ctx.fourier_source.copy() if ctx.fourier_source is not None else None,
        )

    def to_context(self) -> PipelineContext:
        base_image = self.image.copy()
        base_source = self.source_bgr.copy() if self.source_bgr is not None else None
        ctx = PipelineContext(base_image, base_source)
        ctx.fourier_shift = self.fourier_shift.copy() if self.fourier_shift is not None else None
        ctx.fourier_source = (
            self.fourier_source.copy() if self.fourier_source is not None else None
        )
        return ctx


@dataclass
class PipelineCacheEntry:
    output: np.ndarray
    snapshot: ContextSnapshot


class MainWindow(QMainWindow):
    """Top-level Qt window hosting the pipeline editor UI."""

    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("Filter Pipeline Preview")
        self.resize(1400, 900)

        self.original_bgr: Optional[np.ndarray] = None
        self.original_gray: Optional[np.ndarray] = None
        self.preview_bgr: Optional[np.ndarray] = None
        self.preview_gray: Optional[np.ndarray] = None
        self.preview_scale: float = 1.0
        self.preview_max_dim: int = 900
        self.pipeline_steps: List[PipelineStep] = []
        self.region_select_threshold = 2000
        self.pipeline_dirty = False
        self.original_path: Optional[str] = None
        self._preview_cache: List[Optional[PipelineCacheEntry]] = []
        self._full_cache: List[Optional[PipelineCacheEntry]] = []
        self._preview_dirty_index: int = 0
        self._full_dirty_index: int = 0
        self._operation_map: Dict[str, OperationDefinition] = {op.key: op for op in OPERATIONS}
        self._step_id_counter: int = 1
        self._pending_tab_focus: Optional[int] = None

        container = QWidget()
        self.setCentralWidget(container)
        root_layout = QHBoxLayout(container)
        root_layout.setContentsMargins(12, 12, 12, 12)
        root_layout.setSpacing(12)

        self.left_panel = self._build_left_panel()
        self.right_panel = self._build_right_panel()

        root_layout.addWidget(self.left_panel, 1)
        root_layout.addWidget(self.right_panel, 3)

        self.refresh_shortcut = QShortcut(QKeySequence("Ctrl+Alt+Q"), self)
        self.refresh_shortcut.activated.connect(self.refresh_results)

        self._refresh_category_selector()
        self.update_pipeline_display()

    def _build_left_panel(self) -> QWidget:
        panel = QWidget()
        layout = QVBoxLayout(panel)
        layout.setSpacing(12)

        load_button = QPushButton("Load Image")
        load_button.clicked.connect(self.load_image)
        layout.addWidget(load_button)

        refresh_button = QPushButton("Refresh Results (Ctrl+Alt+Q)")
        refresh_button.clicked.connect(self.refresh_results)
        layout.addWidget(refresh_button)

        export_button = QPushButton("Export Results")
        export_button.clicked.connect(self.export_results)
        layout.addWidget(export_button)

        save_pipeline_button = QPushButton("Save Pipeline")
        save_pipeline_button.clicked.connect(self.save_pipeline)
        layout.addWidget(save_pipeline_button)

        load_pipeline_button = QPushButton("Load Pipeline")
        load_pipeline_button.clicked.connect(self.load_pipeline)
        layout.addWidget(load_pipeline_button)

        operation_group = QGroupBox("Operations")
        op_layout = QVBoxLayout(operation_group)

        self.category_selector = QComboBox()
        self.category_selector.currentIndexChanged.connect(self._populate_operation_selector)
        op_layout.addWidget(self.category_selector)

        self.operation_selector = QComboBox()
        op_layout.addWidget(self.operation_selector)

        add_button = QPushButton("Add Operation")
        add_button.clicked.connect(self.add_operation)
        op_layout.addWidget(add_button)

        self.pipeline_list = QListWidget()
        self.pipeline_list.currentRowChanged.connect(self._on_pipeline_selection_changed)
        op_layout.addWidget(self.pipeline_list, 1)

        button_row = QHBoxLayout()
        remove_button = QPushButton("Remove")
        remove_button.clicked.connect(self.remove_operation)
        up_button = QPushButton("Move Up")
        up_button.clicked.connect(lambda: self.reorder_operation(-1))
        down_button = QPushButton("Move Down")
        down_button.clicked.connect(lambda: self.reorder_operation(1))
        button_row.addWidget(remove_button)
        button_row.addWidget(up_button)
        button_row.addWidget(down_button)
        op_layout.addLayout(button_row)

        layout.addWidget(operation_group, 1)

        self.parameter_group = QGroupBox("Parameters")
        self.parameter_panel = ParameterPanel()
        param_layout = QVBoxLayout(self.parameter_group)
        self.operation_enabled_checkbox = QCheckBox("Enable Operation")
        self.operation_enabled_checkbox.stateChanged.connect(self._on_operation_enabled_toggled)
        param_layout.addWidget(self.operation_enabled_checkbox)
        param_layout.addWidget(self.parameter_panel)
        layout.addWidget(self.parameter_group, 1)
        self.operation_enabled_checkbox.setEnabled(False)
        self.operation_enabled_checkbox.setChecked(False)

        layout.addStretch(1)
        return panel

    def _build_right_panel(self) -> QWidget:
        panel = QWidget()
        layout = QVBoxLayout(panel)
        layout.setSpacing(12)

        self.image_tabs = ImageTabWidget()
        self.image_tabs.tabActivated.connect(self._on_result_tab_activated)
        layout.addWidget(self.image_tabs, 1)

        zoom_row = QHBoxLayout()
        zoom_row.setSpacing(8)
        zoom_row.addStretch()
        zoom_out_button = QPushButton("Zoom Out")
        zoom_out_button.clicked.connect(self.image_tabs.zoom_out)
        zoom_row.addWidget(zoom_out_button)
        zoom_reset_button = QPushButton("Actual Size")
        zoom_reset_button.clicked.connect(self.image_tabs.reset_zoom)
        zoom_row.addWidget(zoom_reset_button)
        zoom_in_button = QPushButton("Zoom In")
        zoom_in_button.clicked.connect(self.image_tabs.zoom_in)
        zoom_row.addWidget(zoom_in_button)
        layout.addLayout(zoom_row)

        return panel

    def _populate_operation_selector(self) -> None:
        category = self.category_selector.currentText()
        self.operation_selector.clear()
        for op in OPERATIONS:
            if op.category == category:
                self.operation_selector.addItem(op.label, op.key)
        if self.operation_selector.count() == 0:
            self.operation_selector.addItem("No available operation", None)

    def _refresh_category_selector(self) -> None:
        categories = sorted({op.category for op in OPERATIONS})
        current = self.category_selector.currentText()
        self.category_selector.blockSignals(True)
        self.category_selector.clear()
        self.category_selector.addItems(categories)
        if categories:
            index = self.category_selector.findText(current) if current else 0
            if index < 0:
                index = 0
            self.category_selector.setCurrentIndex(index)
        self.category_selector.blockSignals(False)
        self._populate_operation_selector()

    def load_image(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Image",
            "",
            "Images (*.png *.jpg *.jpeg *.bmp *.tif *.tiff)",
        )
        if not path:
            return
        image = cv2.imread(path, cv2.IMREAD_COLOR)
        if image is None:
            QMessageBox.warning(self, "Load Failed", "The selected file could not be opened.")
            return
        if max(image.shape[:2]) > self.region_select_threshold:
            dialog = RegionSelectionDialog(image, self)
            if dialog.exec_() != dialog.Accepted:
                return
            selection = dialog.get_selection()
            if selection is not None:
                x, y, w, h = selection
                image = image[y : y + h, x : x + w].copy()
        self.original_path = path
        self.original_bgr = image.copy()
        self.original_gray = ensure_gray(self.original_bgr)
        self.preview_bgr, self.preview_scale = self._build_preview_image(self.original_bgr)
        self.preview_gray = ensure_gray(self.preview_bgr)
        self._reset_all_caches()
        self.run_pipeline()

    def save_pipeline(self) -> None:
        if not self.pipeline_steps:
            QMessageBox.information(self, "Nothing to Save", "Add at least one operation before saving the pipeline.")
            return
        default_dir = ""
        default_name = "pipeline.json"
        if self.original_path:
            default_dir = os.path.dirname(self.original_path)
            base = os.path.splitext(os.path.basename(self.original_path))[0] or "pipeline"
            default_name = base + ".json"
        initial_path = os.path.join(default_dir, default_name) if default_dir else default_name
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Save Pipeline",
            initial_path,
            "Pipeline Files (*.json);;All Files (*)",
        )
        if not path:
            return
        data = self._serialize_pipeline()
        try:
            with open(path, "w", encoding="utf-8") as handle:
                json.dump(data, handle, indent=2, ensure_ascii=False)
        except Exception as exc:
            QMessageBox.critical(self, "Save Failed", f"Could not save pipeline:\n{exc}")
            return
        QMessageBox.information(self, "Pipeline Saved", "Pipeline configuration saved successfully.")

    def load_pipeline(self) -> None:
        default_dir = os.path.dirname(self.original_path) if self.original_path else ""
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Load Pipeline",
            default_dir,
            "Pipeline Files (*.json);;All Files (*)",
        )
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as handle:
                data = json.load(handle)
            steps = self._deserialize_pipeline(data)
        except Exception as exc:
            QMessageBox.critical(self, "Load Failed", f"Could not load pipeline:\n{exc}")
            return
        self._apply_pipeline_steps(steps)
        QMessageBox.information(self, "Pipeline Loaded", "Pipeline configuration loaded successfully.")

    def refresh_results(self) -> None:
        if self.original_bgr is None or self.original_gray is None:
            QMessageBox.information(self, "No Image Loaded", "Load an image before refreshing.")
            return
        self.preview_bgr, self.preview_scale = self._build_preview_image(self.original_bgr)
        self.preview_gray = ensure_gray(self.preview_bgr)
        self._invalidate_cache_from(0, preview=True, full=True)
        self.run_pipeline(use_original=True, show_progress=True)

    def _build_preview_image(self, image: np.ndarray) -> Tuple[np.ndarray, float]:
        height, width = image.shape[:2]
        max_dim = self.preview_max_dim
        if max(height, width) <= max_dim:
            return image.copy(), 1.0
        scale = max_dim / max(height, width)
        new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
        resized = cv2.resize(image, new_size, interpolation=cv2.INTER_AREA)
        return resized, scale

    def add_operation(self) -> None:
        key = self.operation_selector.currentData()
        if key is None:
            return
        definition = next((op for op in OPERATIONS if op.key == key), None)
        if definition is None:
            return
        params = {p.name: p.default for p in definition.parameters}
        step = PipelineStep(definition, params)
        current_row = self.pipeline_list.currentRow()
        insert_index = current_row + 1 if current_row >= 0 else len(self.pipeline_steps)
        self.pipeline_steps.insert(insert_index, step)
        self._refresh_step_ids()
        self._ensure_cache_capacity()
        self._invalidate_cache_from(insert_index)
        self.update_pipeline_display()
        self.pipeline_list.setCurrentRow(insert_index)
        self._on_pipeline_selection_changed(insert_index)
        self._pending_tab_focus = insert_index + 1
        self.run_pipeline()

    def remove_operation(self) -> None:
        row = self.pipeline_list.currentRow()
        if 0 <= row < len(self.pipeline_steps):
            self.pipeline_steps.pop(row)
            self._refresh_step_ids()
            self._ensure_cache_capacity()
            self._invalidate_cache_from(row)
            self.update_pipeline_display()
            self.pipeline_list.setCurrentRow(min(row, len(self.pipeline_steps) - 1))
            self.run_pipeline()

    def reorder_operation(self, direction: int) -> None:
        row = self.pipeline_list.currentRow()
        new_row = row + direction
        if 0 <= row < len(self.pipeline_steps) and 0 <= new_row < len(self.pipeline_steps):
            self.pipeline_steps[row], self.pipeline_steps[new_row] = (
                self.pipeline_steps[new_row],
                self.pipeline_steps[row],
            )
            self._refresh_step_ids()
            self._invalidate_cache_from(min(row, new_row))
            self.update_pipeline_display()
            self.pipeline_list.setCurrentRow(new_row)
            self.run_pipeline()

    def _on_pipeline_selection_changed(self, row: int) -> None:
        step = self.pipeline_steps[row] if 0 <= row < len(self.pipeline_steps) else None
        self.operation_enabled_checkbox.blockSignals(True)
        if step is None:
            self.operation_enabled_checkbox.setChecked(False)
            self.operation_enabled_checkbox.setEnabled(False)
        else:
            self.operation_enabled_checkbox.setEnabled(True)
            self.operation_enabled_checkbox.setChecked(step.enabled)
        self.operation_enabled_checkbox.blockSignals(False)
        self.parameter_panel.set_parameters(step, self._on_parameter_changed)
        self._sync_image_tab_with_selection()

    def _on_parameter_changed(self, name: str, value: object) -> None:
        row = self.pipeline_list.currentRow()
        if 0 <= row < len(self.pipeline_steps):
            self.pipeline_steps[row].params[name] = value
            self._invalidate_cache_from(row)
            self.run_pipeline()

    def _on_operation_enabled_toggled(self, state: int) -> None:
        row = self.pipeline_list.currentRow()
        if not (0 <= row < len(self.pipeline_steps)):
            return
        enabled = state == Qt.Checked
        step = self.pipeline_steps[row]
        if step.enabled == enabled:
            return
        step.enabled = enabled
        self._update_pipeline_list_item(row)
        self._invalidate_cache_from(row)
        self.run_pipeline()

    def _on_result_tab_activated(self, index: int) -> None:
        if index <= 0:
            return
        target_row = index - 1
        if not (0 <= target_row < len(self.pipeline_steps)):
            return
        if self.pipeline_list.currentRow() == target_row:
            return
        self.pipeline_list.setCurrentRow(target_row)

    def _reset_all_caches(self) -> None:
        self._preview_cache = []
        self._full_cache = []
        self._preview_dirty_index = 0
        self._full_dirty_index = 0

    def _ensure_cache_capacity(self) -> None:
        length = len(self.pipeline_steps)
        if len(self._preview_cache) > length:
            del self._preview_cache[length:]
        elif len(self._preview_cache) < length:
            self._preview_cache.extend([None] * (length - len(self._preview_cache)))
        if len(self._full_cache) > length:
            del self._full_cache[length:]
        elif len(self._full_cache) < length:
            self._full_cache.extend([None] * (length - len(self._full_cache)))
        self._preview_dirty_index = min(self._preview_dirty_index, length)
        self._full_dirty_index = min(self._full_dirty_index, length)

    def _invalidate_cache_from(
        self,
        start_index: int,
        *,
        preview: bool = True,
        full: bool = True,
    ) -> None:
        length = len(self.pipeline_steps)
        index = max(0, min(start_index, length))
        if preview:
            for i in range(index, len(self._preview_cache)):
                self._preview_cache[i] = None
            self._preview_dirty_index = min(self._preview_dirty_index, index)
        if full:
            for i in range(index, len(self._full_cache)):
                self._full_cache[i] = None
            self._full_dirty_index = min(self._full_dirty_index, index)

    def _format_step_label(self, step: PipelineStep) -> str:
        prefix = f"[{step.step_id}]" if step.step_id > 0 else "[?]"
        base = f"{prefix} {step.definition.label}"
        if not step.enabled:
            base += " (Disabled)"
        return base

    def _update_pipeline_list_item(self, index: int) -> None:
        if 0 <= index < len(self.pipeline_steps) and 0 <= index < self.pipeline_list.count():
            item = self.pipeline_list.item(index)
            if item is not None:
                item.setText(self._format_step_label(self.pipeline_steps[index]))

    def _refresh_step_ids(self) -> None:
        for index, step in enumerate(self.pipeline_steps, start=1):
            step.step_id = index
        self._step_id_counter = len(self.pipeline_steps) + 1

    def _serialize_pipeline(self) -> Dict[str, Any]:
        steps_data = [
            {
                "key": step.definition.key,
                "params": dict(step.params),
                "enabled": step.enabled,
                "id": step.step_id,
            }
            for step in self.pipeline_steps
        ]
        return {"version": 1, "steps": steps_data}

    def _deserialize_pipeline(self, data: Dict[str, Any]) -> List[PipelineStep]:
        if not isinstance(data, dict):
            raise ValueError("Pipeline file is not a JSON object.")
        steps_raw = data.get("steps")
        if not isinstance(steps_raw, list):
            raise ValueError("Pipeline file must contain a 'steps' list.")

        steps: List[PipelineStep] = []
        for entry in steps_raw:
            if not isinstance(entry, dict):
                raise ValueError("Each step entry must be an object.")
            key = entry.get("key")
            if not isinstance(key, str):
                raise ValueError("Step entry missing 'key'.")
            definition = self._operation_map.get(key)
            if definition is None:
                raise ValueError(f"Unknown operation key: {key}")
            params_data = entry.get("params", {})
            if not isinstance(params_data, dict):
                raise ValueError(f"Parameters for '{key}' must be an object.")
            params: Dict[str, Any] = {}
            for param in definition.parameters:
                params[param.name] = params_data.get(param.name, param.default)
            enabled_value = entry.get("enabled", True)
            if isinstance(enabled_value, bool):
                enabled = enabled_value
            elif isinstance(enabled_value, str):
                enabled = enabled_value.strip().lower() not in {"false", "0", "off"}
            else:
                enabled = bool(enabled_value)
            step_id_raw = entry.get("id")
            step_id = int(step_id_raw) if isinstance(step_id_raw, int) and step_id_raw > 0 else 0
            steps.append(PipelineStep(definition, params, enabled=enabled, step_id=step_id))
        return steps

    def _apply_pipeline_steps(self, steps: List[PipelineStep]) -> None:
        self.pipeline_steps = steps
        self._refresh_step_ids()
        self._reset_all_caches()
        self._ensure_cache_capacity()
        self.update_pipeline_display()
        if steps:
            self.pipeline_list.setCurrentRow(0)
        self.run_pipeline()

    def _get_base_images(self, use_original: bool) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
        if use_original:
            base_gray = None if self.original_gray is None else self.original_gray.copy()
            base_bgr = None if self.original_bgr is None else self.original_bgr.copy()
        else:
            base_gray = None if self.preview_gray is None else self.preview_gray.copy()
            base_bgr = None if self.preview_bgr is None else self.preview_bgr.copy()
        return base_gray, base_bgr

    def _compute_pipeline_results(
        self,
        base_gray: np.ndarray,
        base_bgr: Optional[np.ndarray],
        *,
        use_original: bool,
        show_progress: bool,
    ) -> Tuple[List[Tuple[str, np.ndarray]], bool]:
        self._ensure_cache_capacity()
        steps = self.pipeline_steps
        total_steps = len(steps)
        cache = self._full_cache if use_original else self._preview_cache
        dirty_attr = self._full_dirty_index if use_original else self._preview_dirty_index
        dirty_index = min(dirty_attr, total_steps)
        for idx in range(dirty_index):
            if cache[idx] is None:
                dirty_index = idx
                break

        results: List[Tuple[str, np.ndarray]] = []
        if base_bgr is not None:
            results.append(("Original", base_bgr.copy()))
        else:
            results.append(("Original", base_gray.copy()))

        for idx in range(dirty_index):
            entry = cache[idx]
            if entry is None:
                dirty_index = idx
                break
            label = self._format_step_label(steps[idx])
            results.append((label, entry.output.copy()))

        progress_dialog: Optional[QProgressDialog] = None
        cancelled = False
        last_computed_index = dirty_index - 1
        if show_progress and total_steps > 0 and dirty_index < total_steps:
            progress_dialog = QProgressDialog("Running pipeline...", "Cancel", 0, total_steps, self)
            progress_dialog.setWindowModality(Qt.WindowModal)
            progress_dialog.setMinimumDuration(0)
            progress_dialog.setValue(min(dirty_index, total_steps))
            progress_dialog.show()

        ctx: Optional[PipelineContext] = None
        if dirty_index < total_steps:
            if dirty_index == 0:
                ctx = PipelineContext(base_gray.copy(), base_bgr.copy() if base_bgr is not None else None)
            else:
                prev_entry = cache[dirty_index - 1]
                if prev_entry is None:
                    ctx = PipelineContext(base_gray.copy(), base_bgr.copy() if base_bgr is not None else None)
                    dirty_index = 0
                    results = results[:1]  # reset to original only
                    last_computed_index = -1
                    if progress_dialog is not None:
                        progress_dialog.setValue(0)
                else:
                    ctx = prev_entry.snapshot.to_context()

        try:
            for idx in range(dirty_index, total_steps):
                if ctx is None:
                    break
                step = steps[idx]
                label = self._format_step_label(step)
                if step.enabled:
                    result = step.definition.apply_func(ctx, step.params)
                    output_image = result.copy()
                else:
                    output_image = ctx.image.copy()
                snapshot = ContextSnapshot.from_context(ctx)
                cache[idx] = PipelineCacheEntry(output_image.copy(), snapshot)
                results.append((label, output_image.copy()))
                last_computed_index = idx
                if progress_dialog is not None:
                    progress_dialog.setValue(idx + 1)
                    QApplication.processEvents()
                    if progress_dialog.wasCanceled():
                        cancelled = True
                        break
            if progress_dialog is not None and not cancelled:
                progress_dialog.setValue(total_steps)
        finally:
            if progress_dialog is not None:
                progress_dialog.close()
                QApplication.processEvents()  # ensure the close event is processed before heavy UI updates

        next_dirty = max(0, min(last_computed_index + 1, total_steps))
        if use_original:
            self._full_dirty_index = next_dirty
        else:
            self._preview_dirty_index = next_dirty

        return results, cancelled

    def _sync_image_tab_with_selection(self) -> None:
        if self.image_tabs.count() == 0:
            return
        row = self.pipeline_list.currentRow()
        target_index = 0 if row < 0 else row + 1
        clamped_index = max(0, min(target_index, self.image_tabs.count() - 1))
        if self.image_tabs.currentIndex() != clamped_index:
            self.image_tabs.setCurrentIndex(clamped_index)

    def update_pipeline_display(self) -> None:
        self.pipeline_list.clear()
        for idx, step in enumerate(self.pipeline_steps):
            item = QListWidgetItem(self._format_step_label(step))
            self.pipeline_list.addItem(item)
        self._on_pipeline_selection_changed(self.pipeline_list.currentRow())
        self._ensure_cache_capacity()

    def run_pipeline(self, use_original: bool = False, show_progress: bool = False) -> None:
        prev_view_state = self.image_tabs.save_view_state()
        base_gray, base_bgr = self._get_base_images(use_original)
        if base_gray is None:
            self.image_tabs.update_images(
                [("Waiting for image", np.zeros((200, 200), dtype=np.uint8))],
                0,
            )
            return

        results, cancelled = self._compute_pipeline_results(
            base_gray,
            base_bgr,
            use_original=use_original,
            show_progress=show_progress,
        )

        if cancelled:
            self._pending_tab_focus = None
            QMessageBox.information(self, "Pipeline cancelled", "The pipeline execution was cancelled.")
            return

        active_row = self.pipeline_list.currentRow()
        active_index = 0 if active_row < 0 else active_row + 1
        self.image_tabs.update_images(results, active_index)
        restore_state = None if self._pending_tab_focus is not None else prev_view_state
        self.image_tabs.restore_view_state(restore_state, target_index=active_index)
        if self._pending_tab_focus is not None:
            focus_index = max(0, min(self._pending_tab_focus, self.image_tabs.count() - 1))
            self.image_tabs.setCurrentIndex(focus_index)
            self._pending_tab_focus = None

    def _scale_params_for_export(self, key: str, params: Dict[str, object]) -> Dict[str, object]:
        if not params:
            return {}
        scale_factor = 1.0 if self.preview_scale <= 0 else 1.0 / self.preview_scale
        if math.isclose(scale_factor, 1.0, rel_tol=1e-3):
            return dict(params)
        rules = PARAM_SCALE_RULES.get(key)
        if not rules:
            return dict(params)

        scaled: Dict[str, object] = dict(params)
        for name, rule in rules.items():
            if name not in scaled:
                continue
            value = scaled[name]
            if isinstance(value, bool):
                continue
            if rule == PARAM_SCALE_INT:
                scaled_length = float(value) * scale_factor
                scaled_int = max(1, int(round(scaled_length)))
                if isinstance(value, int) and ("kernel" in name or name in {"diameter"}) and scaled_int % 2 == 0:
                    scaled_int += 1
                scaled[name] = scaled_int if isinstance(value, int) else float(scaled_int)
            elif rule == PARAM_SCALE_FLOAT:
                scaled[name] = float(value) * scale_factor
            elif rule == PARAM_SCALE_AREA:
                scaled_area = float(value) * scale_factor * scale_factor
                scaled[name] = max(1, int(round(scaled_area)))
        return scaled

    def _iter_pipeline_results(
        self,
        gray_image: np.ndarray,
        color_source: Optional[np.ndarray],
        copy_results: bool = True,
        scale_for_export: bool = False,
    ) -> Iterable[Tuple[str, np.ndarray]]:
        working_gray = np.array(gray_image, dtype=np.float32, copy=True)
        context_source = color_source.copy() if (copy_results and color_source is not None) else color_source
        ctx = PipelineContext(working_gray, context_source)
        if color_source is not None:
            first = color_source.copy() if copy_results else color_source
            yield ("Original", first)
        else:
            base_image = ctx.image.copy() if copy_results else ctx.image
            yield ("Original", base_image)
        for index, step in enumerate(self.pipeline_steps, start=1):
            label = self._format_step_label(step)
            if step.enabled:
                params = step.params
                if scale_for_export:
                    params = self._scale_params_for_export(step.definition.key, params)
                result = step.definition.apply_func(ctx, params)
                output = result.copy() if copy_results else result
            else:
                output = ctx.image.copy() if copy_results else ctx.image
            yield (label, output)

    def export_results(self) -> None:
        if self.original_gray is None:
            QMessageBox.information(self, "No Image Loaded", "Load an image before exporting results.")
            return
        folder = QFileDialog.getExistingDirectory(self, "Select export folder", "")
        if not folder:
            return
        os.makedirs(folder, exist_ok=True)
        total_steps = len(self.pipeline_steps) + 1
        progress = QProgressDialog("Exporting results...", "Cancel", 0, total_steps, self)
        progress.setWindowModality(Qt.WindowModal)
        progress.setMinimumDuration(0)
        progress.show()
        original_gray = self.original_gray
        original_bgr = self.original_bgr
        error_message: Optional[str] = None
        cancelled = False
        try:
            for idx, (title, image) in enumerate(
                self._iter_pipeline_results(
                    original_gray,
                    original_bgr,
                    copy_results=False,
                    scale_for_export=True,
                ),
                start=0,
            ):
                progress.setValue(idx)
                QApplication.processEvents()
                if progress.wasCanceled():
                    cancelled = True
                    break
                filename = self._make_export_filename(idx, title)
                output_path = os.path.join(folder, filename)
                try:
                    self._save_image(image, output_path)
                except Exception as exc:
                    error_message = f"Failed to export {title}: {exc}"
                    progress.cancel()
                    cancelled = True
                    break
            progress.setValue(total_steps)
        finally:
            progress.close()
        if error_message:
            QMessageBox.critical(self, "Export failed", error_message)
        elif cancelled:
            QMessageBox.information(self, "Export cancelled", "The export operation was cancelled.")
        else:
            QMessageBox.information(self, "Export complete", "All images were saved to the selected folder.")

    def _make_export_filename(self, index: int, title: str) -> str:
        safe = ''.join(ch if ch.isalnum() else '_' for ch in title)
        safe = safe.strip('_') or f"step_{index}"
        return f"{index:02d}_{safe}.png"

    def _save_image(self, image: np.ndarray, path: str) -> None:
        directory, filename = os.path.split(path)
        if not filename:
            filename = "result.png"
        base, ext = os.path.splitext(filename)
        if not ext:
            ext = ".png"
        ext = ext.lower()
        valid_ext = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
        if ext not in valid_ext:
            ext = ".png"
        final_path = os.path.join(directory, base + ext)
        display = to_display(image)
        success, buffer = cv2.imencode(ext, display)
        if not success:
            raise RuntimeError("Failed to encode image for export.")
        buffer.tofile(final_path)
